; (function ($) {
    let _ = $.document
    const context = {
        directions: {
            UP: 0,
            RIGHT: 1,
            DOWN: 2,
            LEFT: 3
        },
        boardClass: {
            blk: 'g-blk',
            blkStyle: val => `g-${val > 2048 ? 'over' : val}`,
            board: 'g-cross',
            btn: 'g-bt',
            score: 'g-score-box',
            btnDis: 'g-bt-dis',
            btnHL: 'g-bt-highlight',
            infoHid: 'g-c-hid',
            infoWin: 'g-c-nn',
            gInfo: 'g-c-info',
            gWin: 'g-c-win'
        },
        states: {
            RUNNING: 0,
            FAILED: 1,
            WIN: 2
        },
    }

    class GameStats {
        constructor() {
            this.score = 0
            this.best = 0
        }

        reset() {
            const best = $.localStorage.getItem('best')
            if (!best) {
                this.best = Number(best)
            }
            this.score = 0
        }

        saveBestLocal() {
            let histBest = $.localStorage.getItem('best')
            histBest = histBest ? histBest : 0
            $.localStorage.setItem('best', histBest && this.best > histBest ? this.best : histBest)
        }
    }

    class GameStatsRenderer {
        constructor() {
            this._gScoreDOM = null
            this._gBestDOM = null
        }
        /**
         * 
         * @param {HTMLElement} sd 
         */
        setScoreDOM(sd) {
            this._gScoreDOM = sd
        }

        /**
         * 
         * @param {HTMLElement} bd 
         */
        setBestDom(bd) {
            this._gBestDOM = bd
        }

        render(score, best) {
            if (score !== undefined) {
                this._gScoreDOM.innerText = score
            }
            if (best !== undefined) {
                this._gBestDOM.innerText = best
            }
        }
    }

    class GameBoard {
        constructor() {
            this.board = []
        }
        /**
         * 
         * @param {number} size 
         * @param {number} initVal
         */
        reset(size = 4, initVal = 0) {
            let newBoard = []
            for (let i = 0; i < size; i += 1) {
                let tmpRow = []
                for (let j = 0; j < size; j += 1) {
                    tmpRow.push(initVal)
                    // this.board[i][j] = initVal
                }
                newBoard.push(tmpRow)
            }
            this.board = newBoard
        }

        getCol(colNum) {
            return this.board.map(e => e[colNum])
        }

        getRow(rowNum) {
            return [...this.board[rowNum]]
        }

        _findAvailBlk() {
            let res = []
            const n = this.board.length
            for (let i = 0; i < n; i += 1) {
                for (let j = 0; j < n; j += 1) {
                    if (this.board[i][j] === 0)
                        res.push([i, j])
                }
            }
            return res
        }

        genRandomBlk() {
            const availBlks = this._findAvailBlk()
            const n = availBlks.length
            const randomVal = () => Math.random > 0.8 ? 4 : 2
            const genRandomPos = () => Math.trunc(Math.random() * (n - 1))
            const genBlk = (arr) => {
                this.board[arr[0]][arr[1]] = randomVal()
            }
            let a = genRandomPos()
            genBlk(availBlks[a])
        }

        /**
         * 
         * @param {number[]} arr //both normal and reverse applies to it, also rows and cols.
         * @returns {boolean}
         */
        _collapsable(arr) {
            for (let i = 0; i < arr.length - 1; i += 1) {
                if (arr[i] === 0) {
                    if (arr[i + 1] > 0) {
                        return true
                    }
                } else {
                    if (arr[i] === arr[i + 1]) {
                        return true
                    }
                }
            }
            return false
        }

        /**
         * 
         * @returns {boolean}
         */
        _rowCollapsable() {
            for (let row of this.board) {
                const reverseRow = [...row].reverse()

                if (this._collapsable(row) || this._collapsable(reverseRow)) {
                    return true
                }
            }
            return false
        }

        /**
         * 
         * @returns {boolean}
         */
        _colCollapsable() {
            const n = this.board[0].length
            for (let i = 0; i < n; i += 1) {
                const col = this.getCol(i)
                const reverseCol = [...col].reverse()
                if (this._collapsable(col) || this._collapsable(reverseCol)) {
                    return true
                }
            }
        }

        /**
         * @returns {boolean}
         */
        hasPossibleMove() {
            return this._rowCollapsable() || this._colCollapsable()
        }

        // if patterns like x 0 x or  x 0 |(swipe end)
        // move to end
        // else try merge same blks
        // to avoid reference, pass value.
        _bubbleZeros(arr) {
            // always same direction move
            // that is, from 0 to n
            // a bubble sort, just for 0
            // need stability for sort
            const n = arr.length
            let moveFlag = false
            for (let i = 0; i < n; i += 1) {
                for (let j = 0; j < n - 1; j += 1) {
                    if (arr[j] && arr[j + 1] === 0) {
                        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
                        moveFlag = true
                    }
                }
            }
            return moveFlag
        }

        // same call as above
        // should return score, or update score
        _mergeSame(arr) {
            // merge forward thus O(n)
            let mergeScore = []
            // for (let i = 0; i < arr.length - 1; i += 1) {
            //     if (arr[i + 1] && arr[i + 1] === arr[i]) {
            //         arr[i + 1] *= 2
            //         arr[i] = 0
            //         mergeScore.push(arr[i + 1])
            //     }
            // }
            // 反向好像是原版的合并方法
            for (let i = arr.length - 1; i > 0; i -= 1) {
                if (arr[i - 1] && arr[i - 1] === arr[i]) {
                    arr[i - 1] *= 2
                    arr[i] = 0
                    mergeScore.push(arr[i - 1])
                }
            }
            return mergeScore
        }

        _scanPattern(arr) {
            for (let i = 0; i < arr.length - 1; i += 1) {
                if (arr[i] && arr[i + 1] === 0) {
                    return true
                }
            }
            return false
        }

        // todos
        // 1. use closure to lock this
        // 2. merge 2 directions (up&down, left&right)
        moveDir(dir) {
            let zeroFlag = false
            let mergeScore = []
            // if (!this._st.available()) {
            //     return {
            //         zeroFlag,
            //         mergeScore
            //     }
            // }
            // if moved, update board
            // if score, update score, update board
            switch (dir) {
                case context.directions.UP: case context.directions.DOWN:
                    const isUp = dir === context.directions.UP
                    for (let i = 0; i < this.board[0].length; i += 1) {
                        const revCol = isUp ? this.getCol(i).reverse() : this.getCol(i)
                        let moved = false
                        // 3-steps: move, merge once, move
                        if (this._scanPattern(revCol)) {
                            moved = this._bubbleZeros(revCol)
                        }
                        mergeScore.push(this._mergeSame(revCol))
                        let scores = mergeScore.flat()
                        if (scores.length > 0 && this._scanPattern(revCol)) {
                            moved = this._bubbleZeros(revCol)
                        }
                        // refill
                        if (moved || scores.length) {
                            zeroFlag = moved ? true : false
                            if (isUp) {
                                revCol.reverse()
                            }
                            // fill back
                            this.board.map((e, j) => {
                                e[i] = revCol[j]
                            })
                        }
                    }
                    break
                case context.directions.LEFT: case context.directions.RIGHT:
                    const isLeft = dir === context.directions.LEFT
                    for (let i = 0; i < this.board.length; i += 1) {
                        // test if change. Yes, so avoid it.
                        const row = isLeft ? this.getRow(i).reverse() : this.getRow(i)
                        let moved = false
                        if (this._scanPattern(row)) {
                            moved = this._bubbleZeros(row)
                        }
                        mergeScore.push(this._mergeSame(row))
                        let scores = mergeScore.flat()
                        if (scores.length > 0 && this._scanPattern(row)) {
                            moved = this._bubbleZeros(row)
                        }
                        if (moved || scores.length) {
                            zeroFlag = true
                            if (isLeft) {
                                row.reverse()
                            }
                            // fill back
                            this.board[i] = row
                        }
                    }
                    break
            }
            // zero flag 决定渲染
            // mergescore 决定分数更新，判胜
            return {
                zeroFlag,
                mergeScore
            }
        }

    }

    class GameBoardRenderer {
        constructor() {
            this._gBoardDOM = null
        }

        /**
         * 
         * @param {HTMLElement[][]} bd 
         */
        setBoardDOM(bd) {
            this._gBoardDOM = bd
        }

        /**
         * 
         * @param {number} val 
         * @return {StyledBlk}
         */
        _createStyledBlk(val) {
            // replace it with global vars
            let classStyle = context.boardClass.blk
            if (val) {
                classStyle += ' ' + context.boardClass.blkStyle(val)
            }
            return new StyledBlk(val, classStyle)
        }

        /**
         * 
         * @param {number[][]} mat 
         * @returns {StyledBlk[][]}
         */
        _createStyledBoard(mat) {
            const res = []
            for (let row of mat) {
                const tmpRow = []
                for (let val of row) {
                    tmpRow.push(this._createStyledBlk(val))
                }
                res.push(tmpRow)
            }
            return res
        }

        /**
         * 
         * @param {number[][]} mat 
         */
        render(mat) {
            mat = this._createStyledBoard(mat)
            const size = this._gBoardDOM[0].length
            for (let rowNum = 0; rowNum < size; rowNum += 1) {
                for (let colNum = 0; colNum < size; colNum += 1) {
                    this._gBoardDOM[rowNum][colNum].innerText = mat[rowNum][colNum].val ? mat[rowNum][colNum].val : ''
                    this._gBoardDOM[rowNum][colNum].classList.value = mat[rowNum][colNum].styleClass
                }
            }
        }
    }

    class StyledBlk {
        /**
         * 
         * @param {number} val 
         * @param {string} styleClass 
         */
        constructor(val, styleClass = "g-blk") {
            this.val = val
            this.styleClass = styleClass
        }
    }

    class GameHistory {
        constructor() {
            this._state = []
            this._score = []
        }

        set state(mat) {
            this._state.push(JSON.parse(JSON.stringify(mat)))
        }

        get state() {
            return this._state.pop() || null
        }

        set histScore(val) {
            this._score.push(val)
        }

        get histScore() {
            return this._score.pop() || null
        }

        getLastState() {
            return {
                histState: this.state,
                histScore: this.histScore
            }
        }

        setState(state, score) {
            this.state = state,
                this.histScore = score
        }

        available() {
            return this._state.length > 0
        }

        reset() {
            this._state = []
            this._score = []
        }
    }

    class GameHistRenderer {
        constructor() {
            this._gHistDOM = null
        }
        /**
         * 
         * @param {HTMLElement} hd 
         */
        setHistDOM(hd) {
            this._gHistDOM = hd
        }
        disableHist() {
            this._gHistDOM.classList.add(context.boardClass.btnDis)
        }
        enableHist() {
            this._gHistDOM.classList.remove(context.boardClass.btnDis)
        }

        /**
         * 
         * @param {boolean} histStatus 
         */
        render(histStatus) {
            if (histStatus) {
                this.enableHist()
            } else {
                this.disableHist()
            }
        }
    }

    class GameSt {
        constructor() {
            this.currState = context.states.RUNNING
        }

        win() {
            this.currState = context.states.WIN
        }

        fail() {
            this.currState = context.states.FAILED
        }
        available() {
            return this.currState === context.states.RUNNING
        }
        reset() {
            this.currState = context.states.RUNNING
        }
    }

    class GameStRenderer {
        constructor() {
            this._gRestartDOM = null
            this._gEndInfoDOM = null
            this._gWinInfoDOM = null
        }

        /**
         * 
         * @param {HTMLElement} sd 
         */
        setStDOM(sd) {
            this._gRestartDOM = sd
        }

        /**
         * 
         * @param {HTMLElement} eid
         */
        setEndInfoDOM(eid) {
            this._gEndInfoDOM = eid
        }
        /**
         * 
         * @param {HTMLElement} wid
         */
        setWinInfoDOM(wid) {
            this._gWinInfoDOM = wid
        }

        highlight() {
            this._gRestartDOM.classList.add(context.boardClass.btnHL)
        }

        reset() {
            this._gRestartDOM.classList.remove(context.boardClass.btnHL)
            this._gEndInfoDOM.classList.add(context.boardClass.infoHid)
            this._gWinInfoDOM.classList.add(context.boardClass.infoWin)
        }

        showEndInfo() {
            this._gEndInfoDOM.classList.remove(context.boardClass.infoHid)
        }

        showWinInfo() {
            this._gWinInfoDOM.classList.remove(context.boardClass.infoWin)
        }

        /**
         * 
         * @param {boolean} state 
         */
        render(state) {
            switch (state) {
                case context.states.RUNNING:
                    this.reset()
                    break
                case context.states.WIN:
                    this.showWinInfo()
                case context.states.FAILED:
                    this.showEndInfo()
                    this.highlight()
                    break
            }
        }
    }

    class GameManager {
        constructor() {

            this._board = new GameBoard()
            this._stats = new GameStats()
            this._hist = new GameHistory()
            this._st = new GameSt()


            this._boardRenderer = new GameBoardRenderer()
            this._statsRenderer = new GameStatsRenderer()
            this._histRenderer = new GameHistRenderer()
            this._stRenderer = new GameStRenderer()
        }

        renderBoard() {
            this._boardRenderer.render(this._board.board)
        }

        renderScore() {
            this._statsRenderer.render(this._stats.score, this._stats.best)
        }

        renderHistory() {
            this._histRenderer.render(this._hist.available())
        }

        renderSt() {
            this._stRenderer.render(this._st.currState)
        }

        updateScore(val) {
            this._stats.score += val
            if (this._stats.score > this._stats.best) {
                this._stats.best = this._stats.score
            }
        }

        stashHistory() {
            return {
                board: JSON.parse(JSON.stringify(this._board.board)),
                hist: {
                    score: this._stats.score,
                    best: this._stats.best
                }
            }
        }

        updateHistory(stash) {
            this._hist.setState(stash.board, stash.hist)
        }

        rollbackHistory() {
            if (this._hist.available() && this._st.available()) {
                let last = this._hist.getLastState()
                this._board.board = last.histState
                this._stats.best = last.histScore.best
                this._stats.score = last.histScore.score
                return true
            } else {
                return false
            }

        }

        isRunning() {
            return this._st.available()
        }

        saveBest() {
            this._stats.saveBestLocal()
        }

        initialization() {
            // cross board
            this._boardRenderer.setBoardDOM(Array.from(_.getElementsByClassName(context.boardClass.board)[0].children).map(e => Array.from(e.children)))
            // scoreboard
            const [scoreBlk, bestBlk] = _.getElementsByClassName(context.boardClass.score)
            this._statsRenderer.setBestDom(bestBlk.children[1])
            this._statsRenderer.setScoreDOM(scoreBlk.children[1])
            // controls
            const [gReturn, gRestart] = _.getElementsByClassName(context.boardClass.btn)
            this._histRenderer.setHistDOM(gReturn)
            this._stRenderer.setStDOM(gRestart)

            // info
            const [gEndInfo] = _.getElementsByClassName(context.boardClass.gInfo)
            const [gWinInfo] = _.getElementsByClassName(context.boardClass.gWin)
            this._stRenderer.setEndInfoDOM(gEndInfo)
            this._stRenderer.setWinInfoDOM(gWinInfo)
            // reset
            this.gameRestart()
            // render
            this.gameRerender()
        }

        gameRestart() {
            this._board.reset()
            this._stats.reset()
            this._hist.reset()
            this._st.reset()
        }

        gameRerender() {
            this.renderScore()
            this.renderBoard()
            this.renderHistory()
            this.renderSt()
        }

        over() {
            this._histRenderer.disableHist()
            this.renderSt()
            this.saveBest()
        }

        start() {
            this._board.genRandomBlk()
            this._board.genRandomBlk()
            this.renderBoard()
        }

        flow(dir) {
                let stash = this.stashHistory()
                let res = this._board.moveDir(dir)
                let flatScore = res.mergeScore.flat()
                // not moved at all and no score happend!
                if (!res.zeroFlag && flatScore.length === 0) {
                    return
                } else {
                    // so we can update board and gen new blk now
                    // attention: only possible move causes a new block
                    this._board.genRandomBlk()
                    let total = 0
                    for (let s of flatScore) {
                        total += s
                    }
                    if (total) {
                        this.updateScore(total)
                        this.renderScore()
                        // ⚠ test if win
                        if (flatScore.some(e => e === 2048)) {
                            this._st.win()
                            this.over()
                        }
                    }


                    this.updateHistory(stash)
                    this.renderHistory()
                    this.renderBoard()
                }
                // game ends if no more possible move
                if (!this._board.hasPossibleMove()) {
                    this._st.fail()
                    this.over()
                    return
                }
        }
    }

    function initGame() {
        let game = new GameManager()
        game.initialization()
        const [gReturn, gRestart] = _.getElementsByClassName(context.boardClass.btn)
        gRestart.addEventListener('click', () => {
            game.gameRestart()
            game.gameRerender()
            game.start()
        })

        gReturn.addEventListener('click', () => {
            let changed = game.rollbackHistory()

            if (changed) {
                game.gameRerender()
            }
        })

        const keyMap = {
            "ArrowUp": context.directions.UP,
            "ArrowRight": context.directions.RIGHT,
            "ArrowDown": context.directions.DOWN,
            "ArrowLeft": context.directions.LEFT
        }

        _.addEventListener('keydown', e => {
            if (!game.isRunning()) {
                return
            } else if (Object.keys(keyMap).some(k => k === e.key)) {
                let dir = keyMap[e.key]
                game.flow(dir)
            }
        })

        const touchManager = {
            vector: {
                x: 0,
                y: 0
            },
            state: {
                quadrant: 1
            },
            setStart(x, y) {
                this.vector.x = x
                this.vector.y = y
            },
            setEnd(x, y) {
                this.vector.x = x - this.vector.x
                this.vector.y = y - this.vector.y
            },
            isMove() {
                const moo = Math.sqrt(this.vector.x ** 2 + this.vector.y ** 2)
                if (moo >= 10) {
                    return true
                } else {
                    this.reset()
                    return false
                }
            },
            reset() {
                this.vector.x = 0
                this.vector.y = 0
                this.state.quadrant = 1
            },
            findQuadrant() {
                // 计算机原点在左上角！！
                if (this.vector.x >= 0) {
                    if (this.vector.y < 0) {
                        this.state.quadrant = 1
                    } else {
                        this.state.quadrant = 4
                    }
                } else {
                    if (this.vector.y < 0) {
                        this.state.quadrant = 2
                    } else {
                        this.state.quadrant = 3
                    }
                }
            },
            findDirection() {
                const ratio = Math.abs(Math.trunc(this.vector.x / (this.vector.y + 0.1)))
                if (ratio >= 1) {
                    if (this.state.quadrant === 2 || this.state.quadrant === 3) {
                        return context.directions.LEFT
                    } else {
                        return context.directions.RIGHT
                    }
                } else {
                    if (this.state.quadrant === 1 || this.state.quadrant === 2) {
                        return context.directions.UP
                    } else {
                        return context.directions.DOWN
                    }
                }
            }
        }

        _.addEventListener('touchstart', e => {
            if (!game.isRunning()) {
                return
            }
            touchManager.setStart(e.touches.item(0).clientX, e.touches.item(0).clientY)
        })

        _.addEventListener('touchend', e => {
            if (!game.isRunning()) {
                return
            }
            touchManager.setEnd(e.changedTouches.item(0).clientX, e.changedTouches.item(0).clientY)
            if (touchManager.isMove()) {
                touchManager.findQuadrant()
                let dir = touchManager.findDirection()
                game.flow(dir)
            }
        })

        game.start()
    }

    initGame()
})(window);


